Skip to content

fix: revert unsafe partial aggregates after final fallback - #5421

Open
sunchao wants to merge 1 commit into
apache:mainfrom
sunchao:dev/chao/codex/oss-unsafe-partial-aggregate-fallback
Open

fix: revert unsafe partial aggregates after final fallback#5421
sunchao wants to merge 1 commit into
apache:mainfrom
sunchao:dev/chao/codex/oss-unsafe-partial-aggregate-fallback

Conversation

@sunchao

@sunchao sunchao commented Aug 22, 2026

Copy link
Copy Markdown
Member

Why are the changes needed?

Closes #5419.

Comet can silently produce incorrect results when an aggregate starts in Comet but finishes in Spark after its shuffle falls back. Although the planner already tries to reject unsafe mixed-engine aggregation, its existing check runs before child operators are converted. A final aggregate can therefore appear convertible early in planning but later remain in Spark because its shuffle child is not native. Its partial aggregate has already been converted to Comet, leaving an execution boundary that the aggregate's intermediate buffer was never declared safe to cross.

For example, consider eight rows spread across four Parquet files, each with amount = CAST(200 AS DECIMAL(20, 2)). With native shuffle disabled, run:

SELECT AVG(amount)
FROM decimal_avg_probe
WHERE id = 1;

Only one partition contains the matching row. Spark correctly returns 200.000000, but Comet returns NULL with adaptive execution either enabled or disabled. The problematic plan is:

Spark HashAggregate [Final AVG]
  Spark ShuffleExchange
    CometHashAggregate [Partial AVG]  <-- unsupported buffer boundary
      CometProject
        CometFilter
          Comet scan conversion

Decimal AVG is explicitly ineligible for mixed Spark/Comet execution because its intermediate state has compatibility requirements beyond simply matching the output data type. In this example, an empty Comet partial contributes a null sum that poisons Spark's final aggregation. The same admission gap can affect other aggregates whose buffers are not declared safe for mixed execution, including distinct aggregations with intermediate merge stages. With AQE enabled, the plan must be repaired before a shuffle stage materializes; afterward, its incompatible buffers have already been produced.

What changes were proposed in this PR?

Base the fallback decision on the execution plan that conversion actually produced, not solely on an early estimate of whether aggregate expressions look convertible. The existing pre-conversion check remains useful, but a new post-conversion reconciliation verifies that any aggregate left on Spark is not consuming an unsupported Comet partial. When such a boundary exists, its feeding partial aggregate and associated aggregation/shuffle chain are restored to their Spark implementations before execution begins.

The corrected plan keeps the incompatible aggregate buffer inside Spark while preserving native work below it:

Spark HashAggregate [Final AVG]
  Spark ShuffleExchange
    Spark HashAggregate [Partial AVG]
      CometColumnarToRow
        CometProject
          CometFilter
            Comet scan conversion

This fallback is deliberately local rather than a blanket retreat to Spark. Distinct-aggregate chains and AQE stage replanning remain consistent, while existing materialized or reused stages are not rewritten. Native scans, filters, and projections below the aggregate stay native; supported mixed aggregates such as MIN and MAX, as well as aggregate pipelines that can run entirely in Comet, retain their existing native execution.

This PR changes neither the mixed-execution eligibility policy nor native accumulator behavior. The separate empty-partial AVG buffer defect is tracked in #5418 and fixed by #5420. Correcting that representation does not eliminate the need for this planner safeguard: decimal AVG and other unsupported aggregate buffers still cannot be freely exchanged between Spark and Comet.

How was this PR tested?

All four new targeted regressions fail on public main and pass after this change. They cover the decimal AVG wrong-result case with AQE disabled and enabled, along with ordinary and distinct aggregate chains when native shuffle cannot be used.

The complete CometAggregateSuite, CometExecRuleSuite, and CometShuffleFallbackStickinessSuite passed on Spark 4.0.4 / Java 17: 121 tests passed, with no failures or aborted suites and two existing Spark-version-gated cancellations. The public-base native library was unchanged. Plan assertions verify AQE materialization, repeated whole-plan and stage-only planning, preserved native operators below the partial, safe mixed MIN/MAX, and fully native aggregate chains.

An independent standalone-JAR replay against stock Spark 4.0.2 confirmed the corrected decimal AVG result with AQE on and off. The separate integer/narrow-decimal empty-partition defect from #5418 remains reproducible when this planner fix is tested alone; applying both fixes together matches Spark in 20/20 synthetic cases. Root-reactor Maven packaging, Spotless, Scala style checks, and git diff --check also passed. GitHub CI reports 63 passing checks, with nine inapplicable checks skipped.

sunchao added a commit to sunchao/arrow-datafusion-comet that referenced this pull request Aug 26, 2026
sunchao added a commit to sunchao/arrow-datafusion-comet that referenced this pull request Aug 26, 2026
@sunchao
sunchao force-pushed the dev/chao/codex/oss-unsafe-partial-aggregate-fallback branch from 644e38f to cfa3113 Compare August 26, 2026 17:36
@sunchao

sunchao commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

cc @andygrove @comphead discovered this correctness issue when running TPC-DS benchmark internally with spark.comet.shuffle.enabled=false

@andygrove
andygrove self-requested a review August 26, 2026 17:41
@andygrove

Copy link
Copy Markdown
Member

I used an LLM (Claude Code) to help with this review, including building both PRs locally and running the sweeps below. I have gone over the results myself and I agree with them, but flagging the tooling up front.

Thanks for digging into this. The gap you identified is real, and the reasoning about canAggregateBeConverted skipping the child-native check matches what the comment in operators.scala says the tagging pass is supposed to cover. Before getting into the mechanics I want to raise something about the framing.

I swept every aggregate function through the Comet-Partial plus Spark-Final configuration, using spark.comet.exec.shuffle.enabled=false so the Final stays in Spark, over 8 rows in 4 Parquet files with a filter that leaves three partials empty. On Spark 4.1 with JDK 17, on current main, only two of the 26 aggregates I tried are wrong across that boundary:

avg(bigint)          comet=[null]                      spark=[1.0]
avg(decimal(20,2))   comet=threw ARITHMETIC_OVERFLOW   spark=[200.000000]

With this PR applied the decimal case is fixed and AVG(bigint) is still wrong, exactly as before. CometAverage.supportsMixedPartialFinal returns true for non-decimal input, so the allowlist waves through what looks like the same defect with the same root cause. That seems like the thing to fix first, and it would be worth a test either way, since it is the same bug this PR is named after.

Everything else the PR newly blocks was already correct in my sweep. COUNT, decimal SUM, FIRST, LAST, and the whole stddev/variance/covariance/correlation family all matched Spark on main, and all of them lose their native Partial with this change. I tried to justify that cost by hunting for a case where the guard earns it: decimal SUM overflowing DECIMAL(38,0), long SUM overflow, TRY_SUM, TRY_AVG, grouped and filtered variants, under ANSI on and off. All of those matched with the native Partial retained, including ANSI throwing and legacy returning null. The comment on CometSum.supportsMixedPartialFinal about overflow detection not surviving the split did not reproduce in this direction, which makes sense to me because that comment describes the Spark-partial to Comet-final direction rather than this one. The same goes for the COUNT exclusion, which the docstring attributes to PropagateEmptyRelationAfterAQE and Spark 4.0 count-bug decorrelation. Both of those only bite when the Final becomes a CometHashAggregateExec.

The other thing I noticed is that #5420 already contains revertUnsafePartialAggregates verbatim, so this PR is a subset of it. When I keep only #5420's native avg.rs and avg_decimal.rs changes and drop the planner guard entirely, both AVG cases are fixed and every aggregate keeps its native Partial.

So on the evidence I have, the native change in #5420 fixes strictly more than this guard does and costs no native aggregation, while this guard fixes less and gives up native partials for a fair number of aggregates. Could you say what this PR adds once #5420 lands? The description asserts the safeguard is still needed after that, but I was not able to construct a case, and none of the four new tests exercises one. If the value is defense in depth against buffer mismatches we have not found yet, rather than a bug that is broken today, that seems like a reasonable position to me, but I think it should be argued on that basis and weighed against the measured cost. It also affects the ordering of the two PRs.

Two smaller things while I was in there. supportsMixedPartialFinal is a single direction-agnostic flag, but most of the exclusions behind it are justified only in the Spark-partial to Comet-final direction. Would it be worth splitting it into two predicates so this pass consults only the one that applies? And where revertChain returns None, the unsafe boundary is left in the plan with no signal, which is the same failure mode the PR exists to prevent. A plain warning there would be too noisy, since I see the None path taken benignly on q10 and q35 where the Partial is already Spark, but gating it on findCometPartialAgg finding a Comet Partial that revertChain failed to reach would make the gap between those two traversals detectable.

One last note, unrelated to this PR. The sweep also turned up that PERCENTILE, APPROX_PERCENTILE, COLLECT_LIST and COLLECT_SET all trip the strict check with spark.comet.exec.shuffle.enabled=false, reporting "Comet did not convert ObjectHashAggregate but recorded no fallback reason". The shuffle-enabled guard on ObjectHashAggregateExec declines without calling withFallbackReason. I will file that separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unsafe native partial aggregates survive child-triggered final fallback

2 participants